feat(shopping): add Fee Extension for checkout and cart (Option B)#245
feat(shopping): add Fee Extension for checkout and cart (Option B)#245maximenajim wants to merge 5 commits intomainfrom
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
Implement structured fee support via dev.ucp.shopping.fee extension, enabling businesses to surface itemized fees (service, handling, recycling, etc.) with allocation breakdowns, taxability, and waivability metadata. Extracts shared allocation type to decouple fee and discount extensions. Closes #219.
080e343 to
c491a72
Compare
docs/specification/fee.md
Outdated
| !!! note "Partial adoption" | ||
| A business MAY support fees on checkout only, cart only, or both. Each | ||
| `extends` entry is independent. Platforms should check which base | ||
| capabilities the fee extension extends before expecting `fees` in responses. |
There was a problem hiding this comment.
I'm curious if this needs to be in docs/specification/overview.md, as this should be true for all extensions, correct?
There was a problem hiding this comment.
Good call — partial adoption is indeed a general extension pattern. I'll open a follow-up to add guidance in overview.md about how platforms should handle extensions that declare multiple extends entries. For now the note stays here so this spec is self-contained, but the broader principle should live in the overview.
| | Total Type | Description | | ||
| | ---------------- | ------------------------------------------------------------- | | ||
| | `subtotal` | Sum of line item prices before any adjustments | | ||
| | `items_discount` | Discounts allocated to line items | | ||
| | `discount` | Order-level discounts (shipping, flat amount) | | ||
| | `fulfillment` | Shipping, delivery, or pickup costs | | ||
| | `tax` | Tax amount | | ||
| | `fee` | **Single aggregated fee total** — sum of all `fees.applied[]` | | ||
| | `total` | Grand total: `subtotal - discount + fulfillment + tax + fee` | |
There was a problem hiding this comment.
Should this table be here under docs/specification/fee.md or should fee.md just call out the new total type? I ask because it could make the docs harder to maintain if every extension that affects totals recreates the totals table (e.g. Discount also could have this table).
There was a problem hiding this comment.
Fair concern about maintenance. I kept the table here so the fee spec is self-contained for readers — but you're right that if every extension duplicates it, drift is inevitable. For now I'll keep it with a note that the canonical source is the checkout/totals spec. We could consider a shared include or a cross-reference pattern in a follow-up.
| | `discount` | Order-level discounts (shipping, flat amount) | | ||
| | `fulfillment` | Shipping, delivery, or pickup costs | | ||
| | `tax` | Tax amount | | ||
| | `fee` | **Single aggregated fee total** — sum of all `fees.applied[]` | |
There was a problem hiding this comment.
This language is helpful, and we should consider being this crisp with the other total types.
There was a problem hiding this comment.
Thanks — agreed, we should bring this level of precision to the other total types as well. Happy to help with that in a follow-up pass across the spec.
docs/specification/fee.md
Outdated
| Businesses MUST ensure this invariant holds. If a platform detects a mismatch | ||
| between the aggregated fee total and the sum of itemized fees, the platform | ||
| SHOULD treat the response as invalid and MUST NOT complete the checkout without | ||
| surfacing the discrepancy to the user. |
There was a problem hiding this comment.
Even if the discrepancy is surfaced, is it appropriate for the platform to proceed? Should the Platform use the continue_url is such a case? If yes, should the continue_url be a required field? Currently, it doesn't seem like we have a good way for platforms to handle these cases.
There was a problem hiding this comment.
Great question. Updated in d03ce30 — softened the language from "MUST NOT complete" to "SHOULD surface the discrepancy" and explicitly added continue_url as a fallback option:
Platforms MAY use
continue_urlto hand off to the business UI for resolution rather than attempting to complete the checkout with inconsistent fee data.
I didn't make continue_url required since it's already an optional field on checkout — making it required just for fee mismatch cases would be a broader spec change. But the guidance now gives platforms a clear escape hatch.
| SHOULD treat the response as invalid and MUST NOT complete the checkout without | ||
| surfacing the discrepancy to the user. | ||
|
|
||
| !!! note "When the Fee Extension is absent" |
There was a problem hiding this comment.
This still feels a bit ambiguous. Does this note deserve the same normative language as the Invariant above? That is, "totals[type=fee] MUST represent the aggregated fee total"; or alternatively, something about there MAY be n number of totals[type=fee] objects in totals (the question that sparked issue #219 ).
There was a problem hiding this comment.
Updated in d03ce30 — the admonition now uses explicit normative language:
When the Fee Extension is present, there MUST be at most one
totals[]entry withtype: "fee", and itsamountMUST equalsum(fees.applied[].amount). When the Fee Extension is not advertised, the interpretation of anytotals[type=fee]entry is business-defined — platforms SHOULD render it usingdisplay_textbut MUST NOT assume itemized fee data is available.
This addresses both the "how many fee totals?" question from #219 and the absent-extension semantics.
| }, | ||
| "amount": { | ||
| "$ref": "amount.json", | ||
| "description": "Amount allocated to this target in ISO 4217 minor units." |
There was a problem hiding this comment.
We should adopt this across the spec. In other places we are simply saying "minor units (cents)" which is inadequate for all currencies.
There was a problem hiding this comment.
Agreed — "minor units (cents)" is USD-centric and inadequate. Happy to help with a follow-up sweep to adopt consistent ISO 4217 language across all monetary fields in the spec.
| "properties": { | ||
| "id": { | ||
| "type": "string", | ||
| "description": "Unique fee identifier. Unlike applied discounts, fees always require an id to enable stable referencing across updates." |
There was a problem hiding this comment.
Are fee ids expected to be consistent across checkout sessions? Or just across requests within a checkout session or cart?
There was a problem hiding this comment.
Within a session only. Updated in d03ce30 — both the schema description and the docs now clarify:
Fee IDs are scoped to a single checkout or cart session. The same fee retains its
idacross requests within a session (create → update → complete), but theidis not guaranteed to be consistent across separate sessions. Businesses control ID generation.
This gives platforms stable tracking within a session (which is the primary use case for UI diffing) without imposing a cross-session uniqueness burden on businesses.
| }, | ||
| "description": { | ||
| "type": "string", | ||
| "description": "Optional explanation of why the fee is charged." |
There was a problem hiding this comment.
Also human-readable, I assume. As with conversations around disclosure, I wonder how Platforms should navigate Businesses potentially wanting richer text, or even images.
There was a problem hiding this comment.
Updated in d03ce30 — the description field is now explicitly marked as plain text in both the schema and the spec. For richer content needs, the spec now includes a forward reference:
The
descriptionfield is plain text — for richer content such as regulatory disclosures, images, or formatted copy, businesses should use the Disclosures capability (see #222) when available.
This keeps the fee schema simple while pointing to the right extension point for richer content.
| | `service` | General service fee for order processing | Platform service fee | | ||
| | `handling` | Physical handling and packaging of goods | Oversized item handling fee | | ||
| | `recycling` | Disposal or recycling of materials | Electronics recycling fee | | ||
| | `processing` | Payment or order processing surcharge | Credit card processing fee | | ||
| | `regulatory` | Government-mandated fee or compliance charge | Mattress recycling surcharge | | ||
| | `convenience` | Fee for using a particular ordering channel | Online ordering convenience fee | | ||
| | `restocking` | Fee for processing returns or exchanges | Return restocking fee | | ||
| | `environmental` | Environmental impact or sustainability surcharge | Carbon offset fee | |
There was a problem hiding this comment.
What is the value of this spec defining types, especially so many of them? Does a Platform need to know the various types?
There was a problem hiding this comment.
The well-known types serve interoperability — they let platforms build smarter UX when they recognize a type (e.g., grouping all recycling fees, or showing a specific icon for service fees). But they're explicitly not prescriptive: fee_type is an open string, unknown values are fine, and platforms SHOULD fall back to displaying the title.
Think of it like HTTP status codes — the well-known ones enable richer behavior, but the system works with unknown ones too. The table documents common patterns we've seen across commerce platforms to reduce the "what should I call this?" guesswork.
|
|
||
| 3. **Positive amounts only:** All fee amounts use `exclusiveMinimum: 0` — | ||
| zero-amount fees are not permitted. If a fee does not apply, it MUST be | ||
| omitted from the `applied` array entirely. This includes waived fees: when a |
There was a problem hiding this comment.
Is there perhaps a need to communicate to the Platform / User that a fee was waived? If we do not allow zero-amount fees, how should this be communicated in the response?
There was a problem hiding this comment.
Great question. Updated in d03ce30 — added guidance for using messages[] to communicate waived fees:
{
"messages": [
{
"type": "info",
"code": "fee_waived",
"content": "Service Fee waived for loyalty members."
}
]
}This uses the existing messages mechanism rather than inventing a fee-specific signaling pattern. The code: "fee_waived" gives platforms something to key on programmatically, and the content provides the human-readable explanation.
| operations, meaning platforms never send fee data in requests — fees are | ||
| determined entirely by the business and returned in responses. | ||
|
|
||
| Business receivers MUST reject any `fees` fields provided by platforms in |
There was a problem hiding this comment.
Shall we add an error code to source/schemas/shopping/types/error_code.json for this?
There was a problem hiding this comment.
Done in d03ce30 — added readonly_field_not_allowed to source/schemas/shopping/types/error_code.json examples, and referenced it in the fee spec:
Businesses SHOULD use the
readonly_field_not_allowederror code when rejecting such requests.
This follows the existing pattern where error_code.json uses examples (not enum) for well-known codes.
| "amount": 500, | ||
| "fee_type": "recycling", | ||
| "taxable": true, | ||
| "description": "State-mandated electronics recycling fee." |
There was a problem hiding this comment.
This made me think of #222 . Is a plain-text description adequate, or are there fees that may need to be more expressive; perhaps we handle those with disclosures?
There was a problem hiding this comment.
Exactly — same thought. Updated in d03ce30 to add a forward reference to #222:
The
descriptionfield is plain text — for richer content such as regulatory disclosures, images, or formatted copy, businesses should use the Disclosures capability (see #222) when available.
Plain text covers the 80% case (simple one-liner like "State-mandated recycling fee"). For the 20% that needs formatted copy, images, or regulatory legalese, Disclosures is the right extension point. Keeping description as plain text avoids the sanitization complexity of allowing HTML/markdown in every fee object.
- Soften mismatch language; add continue_url as fallback option (#4) - Strengthen normative admonition with MUST/MUST NOT language (#5) - Scope fee IDs to session; clarify in schema description and docs (#7) - Mark description as plain text; reference Disclosures #222 (#8/#12) - Add messages[] guidance with fee_waived example for waived fees (#10) - Add readonly_field_not_allowed error code; reference in fee spec (#11)
- Soften mismatch language; add continue_url as fallback option (#4) - Strengthen normative admonition with MUST/MUST NOT language (#5) - Scope fee IDs to session; clarify in schema description and docs (#7) - Mark description as plain text; reference Disclosures #222 (#8/#12) - Add messages[] guidance with fee_waived example for waived fees (#10) - Add readonly_field_not_allowed error code; reference in fee spec (#11) Co-authored-by: Dayton <31824+SVDEA001@users.noreply.git.target.com>
…to admonition - Use multi-parent extends array per overview spec (#multi-parent-extensions) - Convert trailing blockquote to admonition for consistency with rest of doc
| ``` | ||
|
|
||
| !!! note "Partial adoption" | ||
| A business MAY support fees on checkout only, cart only, or both. When |
There was a problem hiding this comment.
Suggestion: While the current language allows for independent extension of cart and checkout, we should still guide implementors to ensure a stable state machine and avoid 'price surprises.' Late-stage disclosure is particularly disruptive for platform budget validation and agentic flows.
I suggest adding a perspective on the expected merchant/customer experience to maximize transparency:
-
Early Disclosure: A business SHOULD include fees at the cart level whenever the fee criteria (e.g., item types or subtotal thresholds) are known. Fees SHOULD NOT be deferred to checkout unless they are strictly dependent on data only available at that stage (e.g., payment method surcharges).
-
Disclosure Continuity: A business SHOULD include fees at checkout if they were provided at the cart level to ensure continuity of disclosure granularity and avoid a 'lossy' state transition.
This helps ensures that the merchant-controlled portion of the cost is deterministic as early as possible and remains consistent throughout the transaction lifecycle.
There was a problem hiding this comment.
Thanks for the suggestion — this has been addressed in 47b815d. Added a new "Fee disclosure timing" admonition right after the "Partial adoption" note with both of your recommended guidelines:
- Early disclosure: fees SHOULD be surfaced at cart level when criteria are known; SHOULD NOT be deferred to checkout unless dependent on checkout-only data.
- Disclosure continuity: fees provided at cart level SHOULD carry through to checkout to avoid a lossy state transition.
Uses SHOULD-level normative language to keep it guidance rather than a hard requirement.
5585e8d to
ffe1116
Compare
Add 'Fee disclosure timing' admonition to fee spec with two SHOULD-level guidelines: early disclosure of fees at cart level when criteria are known, and disclosure continuity from cart to checkout to avoid lossy state transitions. Addresses PR #245 review feedback from gsmith85.
docs/specification/fee.md
Outdated
| Businesses MUST ensure this invariant holds. If a platform detects a mismatch | ||
| between the aggregated fee total and the sum of itemized fees, the platform | ||
| SHOULD treat the response as potentially invalid and SHOULD surface the | ||
| discrepancy to the user. Platforms MAY use `continue_url` to hand off to the |
There was a problem hiding this comment.
Suggestion: If sum(fees.applied[].amount) < totals[type=fee].amount escalate. Otherwise, this could put the user in a position where they're paying unspecified fees through an intermediary (the platform) which complicates dispute resolution.
Relatedly, has the TC considered strategies to ensure that the totals in the checkout object itself are consistent (i.e. follow the math you have documented above?) .
There was a problem hiding this comment.
Good catch — updated in 81ea0fa. The mismatch language now distinguishes between the two directions:
- Aggregated total exceeds itemized sum: platform MUST treat as an error and MUST NOT complete checkout — the user would otherwise pay unspecified fees through an intermediary, complicating dispute resolution. Platforms SHOULD use
continue_urlfor resolution. - Aggregated total less than itemized sum: platform SHOULD surface the discrepancy but MAY proceed, since the user is not being overcharged.
Regarding TC strategies for totals consistency validation — that's a great broader question. The invariant enforcement here is fee-specific, but a general totals consistency check (ensuring the math holds across all total types) would be a valuable follow-up discussion for the TC. Happy to open an issue for that.
docs/specification/fee.md
Outdated
| | `processing` | Payment or order processing surcharge | Credit card processing fee | | ||
| | `regulatory` | Government-mandated fee or compliance charge | Mattress recycling surcharge | | ||
| | `convenience` | Fee for using a particular ordering channel | Online ordering convenience fee | | ||
| | `restocking` | Fee for processing returns or exchanges | Return restocking fee | |
There was a problem hiding this comment.
Nit: restocking is a post-purchase/reverse-logistics event. Omit it for the time being?
There was a problem hiding this comment.
Agreed — removed in 81ea0fa. Restocking is a post-purchase/reverse-logistics event and doesn't belong in a checkout/cart fee context. It can always be added back later if a returns-specific extension needs it.
| "total" | ||
| ], | ||
| "description": "Type of total categorization.", | ||
| "description": "Type of total categorization. When the Fee Extension is present, businesses SHOULD include a single aggregated entry with type 'fee' whose amount equals the sum of all fees in the Fee Extension's applied array.", |
There was a problem hiding this comment.
Observation: The lack of schema level enforcement here and the one-off carve out for discounts feed into a strong preference for Approach A (#219).
There was a problem hiding this comment.
Appreciate the observation. The tension between schema-level enforcement and extension flexibility is real — the current approach (Option B) trades stricter schema coupling for independent extensibility, which was the TC's preference for this iteration. That said, the concern about one-off carve-outs is valid and worth revisiting as more extensions land. If the pattern becomes unwieldy, migrating toward Approach A (or a hybrid) could be a future discussion. Happy to capture this as a tracked consideration if helpful.
| limitations under the License. | ||
| --> | ||
|
|
||
| # Fee Extension |
There was a problem hiding this comment.
Alternative: Polymorphic Composition
We can use JSON Schema's allOf to create a FeeTotal that extends the base Total object which achieves the goal of providing fees structured metadata (per: #219). This allows us to keep a unified totals[] array while providing the "slots" for metadata only when type == fee.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/shopping/types/fee_total.json",
"title": "FeeTotal",
"allOf": [
{ "$ref": "total.json" },
{
"type": "object",
"properties": {
"type": {
"const": "fee"
},
"description": {
"type": "string",
"description": "Optional plain-text explanation of why the fee is charged. For richer content (e.g., regulatory disclosures), see the Disclosures capability."
},
"fee_type": {
"type": "string",
"description": "Type of fee (open string). Well-known values: service, handling, recycling, processing, regulatory, convenience, restocking, environmental. Platforms SHOULD handle unknown values gracefully by displaying the fee title to the user."
},
"taxable": {
"type": "boolean",
"default": false,
"description": "Whether this fee is subject to tax."
}
}
}
]
}
There was a problem hiding this comment.
Interesting alternative — the polymorphic FeeTotal via allOf composition is a clean pattern. It would keep the unified totals[] array while adding structured metadata slots only when type == fee, which addresses the core goal from #219.
The tradeoff vs. the current approach: it moves fee metadata into the totals array (closer to where the aggregated amount lives) rather than a separate fees extension object. This simplifies the schema graph but means platforms need to handle polymorphic totals entries — some with just type/amount/display_text, others with additional fee-specific fields.
I think this is worth a deeper TC discussion as a potential evolution, especially if other extensions (e.g., fulfillment surcharges) would benefit from the same pattern. Want to open a discussion issue to explore this alongside the Approach A consideration?
There was a problem hiding this comment.
Thanks, Maxime -- appreciate the receptiveness to the alternative. I agree that the tradeoff on polymorphic handling is the right pivot to discuss, especially if we can use it to standardize how 'why' metadata is attached to the ledger across other extensions (discounts, fulfillment, etc.).
Per your suggestion, I’ve opened a Discussion issue here to dive into the 'Unified Ledger' pattern: #258
| "amount": 1500, | ||
| "fee_type": "handling", | ||
| "allocations": [ | ||
| { "path": "$.fulfillment.options[0]", "amount": 1500 } |
There was a problem hiding this comment.
The waivable flag was a bit of an "aha" moment for me that leaves me oppositional to Plan B. It highlights why separating discounts and fees may be a mistake: in any robust POS or financial model, these are simply adjustments to a base price.
The problem with the aggregate-fee with omissions scheme is that merchants (e.g. DoorDash) don't just hide a waived (delivery) fees, they often display the fee as a line item followed by the corresponding credit. This debit-credit pair serves two purposes:
- Value Signaling: It explicitly proves the discount was applied and reinforces the service's value.
- Audit Integrity: It maintains a mathematically sound record rather than forcing the platform to guess why a field disappeared or how a taxable fee impacted the final sum.
We should aspire to a totals ledger that is governed by a simple deterministic invariant: total = Subtotal + Sum(Adjustments).
I believe the alternative (#245 (comment)) suggested above may allow you to address the "No structured metadata" limitation of Option A, while avoiding the loose contracts of Option B.
…fee types - Strengthen fee mismatch invariant with directional escalation: aggregated total exceeding itemized sum MUST be treated as an error (unspecified fees through intermediary); less-than case MAY proceed with caution - Remove restocking from well-known fee types (post-purchase event, not applicable to checkout/cart context) Addresses PR #245 review feedback from gsmith85.
|
@maximenajim @gsmith85 great discussion in the comments. I started typing review replies but then found myself cross-linking N-ways into sub-threads and finally hitting a 🤦🏻🤦🏻🤦🏻 wall with the extension route -- which, coming in into this review, was my leading contender. Curious to hear your thoughts on this: #261 It's in the intro of the draft, but the key thing that "broke" extension route for me was the mandatory display vs optional nature of extensions. I initially went to draft the mandatory N=1 in totals, but then realized that this would make extensions required to conduct certain shapes of commerce.. which is an unnecessary complication we can avoid because it's a pure presentation concern, not a negotiation requirement. See the rest in the draft above. |
Summary
Implements the Fee Extension (
dev.ucp.shopping.fee) per Issue #219 (Option B), enabling businesses to surface itemized fees on checkout sessions and carts.Key design decisions
types/allocation.jsonas a reusable type referenced by both discount and fee extensions — avoids cross-extension$refcoupling between fee and discount schemasfee_typerequired: Fees without a type are ambiguous for platform rendering/categorizationexclusiveMinimum: 0: Zero-amount fees are not permitted — waived fees are omitted from the array entirelyadditionalProperties: falseon fee type for strict validationreadOnly: trueonfees.appliedfor generic tooling/codegen compatibility$reftoamount.jsonon all monetary fields to preserve the canonical amount type chaintotal.jsondescriptions ("When the Fee Extension is present…")Files changed (8)
New (4):
source/schemas/shopping/types/allocation.json— Shared allocation typesource/schemas/shopping/types/fee.json— Individual fee schemasource/schemas/shopping/fee.json— Fee Extension schema (checkout + cart)docs/specification/fee.md— Full specification pageModified (4):
source/schemas/shopping/discount.json— Allocation now$refs shared typesource/schemas/shopping/types/total.json— Conditional fee language in descriptionsmkdocs.yml— Nav + llmstxt entries.cspell/custom-words.txt— New termsSpec highlights
continue_urlfor resolutiontitle/description/display_textare plain text — renderers MUST escapemessages[]to communicatereadonly_field_not_allowedadded for fee field rejectionextendsarray form per overview specCloses #219
Related: #220